package com.kamistoat.meimeistore.gateway.filter;

import com.alibaba.fastjson2.JSON;
import com.kamistoat.meimeistore.apisystem.domain.SysUserOnline;
import com.kamistoat.meimeistore.apisystem.model.LoginUser;
import com.kamistoat.meimeistore.commoncore.constant.CacheConstants;
import com.kamistoat.meimeistore.commoncore.constant.SecurityConstants;
import com.kamistoat.meimeistore.commoncore.constant.TokenConstants;
import com.kamistoat.meimeistore.commoncore.domain.R;
import com.kamistoat.meimeistore.commoncore.utils.JwtUtils;
import com.kamistoat.meimeistore.commoncore.utils.ServletUtils;
import com.kamistoat.meimeistore.commoncore.utils.StringUtils;
import com.kamistoat.meimeistore.commonredis.utils.RedissonUtils;
import com.kamistoat.meimeistore.gateway.config.properties.IgnoreWhiteProperties;
import io.jsonwebtoken.Claims;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequest.Builder;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.concurrent.TimeUnit;

/**
 * 网关全局拦截器。是一个前端请求到达后端时的第一个处理程序。
 * 尤其注意，GlobalFilter是gateway的全局过滤器，默认生效且默认对所有请求进行过滤。
 * 使用该全局拦截器，首先验证请求是否携带用户令牌，如果携带，则将响应式请求转换为传统阻塞请求。
 * 注意!!!! 这里只是验证请求是否携带用户令牌并验证。
 * 如果不通过直接在网关模块拦截，减轻后面的压力。但是如果携带了，直接放行，并没有填充ThreadLocal的内容(肯定的，在这填充屁用没有，因为一转发服务就新线程了)。
 * 填充ThreadLocal是在 Security模块的HeaderInterceptor 中进行
 */
@Component
public class AuthFilter implements GlobalFilter, Ordered {
    private static final Logger LOGGER = LoggerFactory.getLogger(AuthFilter.class);

    @Autowired
    IgnoreWhiteProperties ignoreWhiteProperties;

    @Autowired
    RedissonUtils redissonUtils;

    /**
     * GlobalFilter 的唯一实现类。该接口是 SpringCloud gateway携带的库类.
     * 验证 前端是否携带简易用户令牌，以及这个简易用户令牌是否可以成功解析，并且和缓存中保存的后端登录令牌相对应。
     * 注意，验证 前端的简易用户令牌 和 缓存中的后端登录令牌，在整个项目中只在网关这里进行一次。
     * <p>
     * 一旦通过后，则将网关的响应式请求，转换为普通的阻塞式请求。然后进行请求转发。
     * 后面就是商城的思路，使用 HeaderInterceptor 根据前端简易令牌 查询 核心信息令牌，并存放在 ThreadLocal中，然后之后所有的鉴权，包括权限验证，都是使用 ThreadLocal来完成的。
     * <p>
     * 很明显因为现在这个项目是管理系统，是完全不允许未登录的匿名用户进系统的，所以网关这里就是只要不携带jwt令牌，直接就拦死了。
     * 如果想要在商场项目中使用这种网关，那么可以在白名单那里使用正则匹配，例如放行所有商品模块的请求: /product/**
     * <p>
     * 另外注意，/auth/login 请求并不拦截，也就是对于 /auth/login，既不携带用户令牌，成功登录后也没有令牌被存放在 ThreadLocal中。
     * 只是会将LoginUser存放在缓存中，并返回给前端一份简易用户令牌。这样前端以后的访问就可以携带上用户令牌，之后就是这个过滤器做的事情了。
     *
     * @param exchange 拦截器拦截
     * @param chain    网关过滤器链
     * @return
     */
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 获取响应式Request
        ServerHttpRequest serverHttpRequest = exchange.getRequest();
        // 获取响应式Request的Builder
        Builder reqBuilder = serverHttpRequest.mutate();
        // 获取当前请求的目标url
        String targetUrl = serverHttpRequest.getURI().getPath();

        // 如果当前请求的目标url 是 白名单中的url，则直接放行向下执行过滤链。不验证登录/权限
        if (StringUtils.matches(targetUrl, ignoreWhiteProperties.getWhites())) {
            return chain.filter(exchange);
        }

        // 获取响应式请求中携带的jwt简易用户令牌
        String jwtToken = getJwtToken(serverHttpRequest);
        if (StringUtils.isEmpty(jwtToken)) {
            return unauthorizedResponse(exchange, "前端携带的用户令牌为空");
        }

        // 从jwt简易令牌中解析出k-v对
        Claims claims = JwtUtils.parseToken(jwtToken);
        // 从k-v对中解析出三个标识变量
        String userId = JwtUtils.getUserId(claims);
        String userKey = JwtUtils.getUserKey(claims);
        String userName = JwtUtils.getUserName(claims);
        if (StringUtils.isNull(claims) || StringUtils.isNull(userId) || StringUtils.isNull(userKey) || StringUtils.isNull(userName)) {
            return unauthorizedResponse(exchange, "前端携带的用户令牌已过期或无法使用指定秘钥正确解析用户令牌");
        }

        // 使用jwt解析出userId，拼接成完整的缓存key后去缓存中寻找对应的 后端登录令牌. 如果缓存中的 后端登录令牌 已经过期则说明整个登录状态过期
        // 取得的 LoginUser 中只有 token userId userName ippr四个数据
        Object cacheObject = redissonUtils.getCacheObject(getTokenCacheKey(userId, userKey));
        LoginUser loginUserCache = JSON.parseObject(String.valueOf(cacheObject), LoginUser.class);
        // 并验证uuid
        if (StringUtils.isNull(loginUserCache)) {
            return unauthorizedResponse(exchange, "登录状态已经过期，或您的核心权限发生变更。请重新登录以获取最新权限");
        }

        // 自动登录验证正确，则刷新缓存中 后端登录令牌 的过期时间。
        // 注意只是延长 后端登录令牌 的过期时间。核心信息令牌不自动延长，只在手动刷新时延长过期时间
        refreshToken(loginUserCache);

        // 记录在线用户。写的很多获取IP的方法都是针对 HttpServletRequest的，而这里是 HTTPServerRequest，是个失误。
        // 最好不要把此方法写在更底层的拦截器中。因为客户端的一个请求，服务端可能会在调用很多个模块后才处理完。
        // 在网关中只需要处理一次，但是写在拦截器中则会被处理很多次（每跳一次模块就会被拦截一次）
        refreshOnlineUser(loginUserCache);

        /**
         * 响应式请求转阻塞式请求
         */
        // 将从jwt中解析出来的各种信息，直接以k-v对添加到请求的header中。
        // 这样之后的 ThreadLocal直接从请求头中获取令牌信息来构建 LoginUser
        addHeader(reqBuilder, SecurityConstants.USER_KEY, userKey);
        addHeader(reqBuilder, SecurityConstants.DETAILS_USER_ID, userId);
        addHeader(reqBuilder, SecurityConstants.DETAILS_USERNAME, userName);
        // 由于网关这里的拦截一定是整个请求的最初始，所以初始化一下，保证 FROM_SOURCE字段为空
        removeHeader(reqBuilder, SecurityConstants.FROM_SOURCE);

        // 将携带上了一系列请求头数据的 ServerHttpRequest传递到过滤链的下一个
        // chain.filter(传入ServerWebExchange)
        // exchange.mutate() -- 获取当前 ServerWebExchange 的构造器(构造器将携带已经存在的参数构建)
        // exchange.mutate().request() -- 使用exchange的构造器来为exchange添加ServerHttpRequest
        // reqBuilder.build() -- 使用ServerHttpRequest的构造器来构建一个ServerHttpRequest
        // 最后再使用 exchange的构造器.build() 把exchange重新构造出来，这样重新构造的exchange不仅携带已经存在的参数，也包含了更新了Header的ServerHttpRequest
        return chain.filter(exchange.mutate().request(reqBuilder.build()).build());
    }

    /**
     * 该过滤器在过滤器链中的排序。
     *
     * @return
     */
    @Override
    public int getOrder() {
        return -200;
    }

    /**
     * 向 响应式请求 的请求头中添加k-v对
     *
     * @param reqBuilder 响应式请求 ServerHttpRequest 的构造器类
     * @param name       k
     * @param value      v
     */
    private void addHeader(Builder reqBuilder, String name, Object value) {
        if (StringUtils.isNotNull(value)) {
            String valueStr = value.toString();
            String valueEncode = ServletUtils.urlEncode(valueStr);
            // 使用 builder 向 ServerHttpRequest 请求头中添加新的k-v
            // 由于这里是使用builder，所以操作和直接操作 HttpRequest并不相同。
            // 顺便一提，ServerHttpRequest 和 ServletHttpRequest在构造出来后的操作方法基本相同
            reqBuilder.header(name, valueEncode);

        }
    }

    /**
     * 从 响应式请求 的请求头中移除 k-v对
     *
     * @param reqBuilder 响应式请求 ServerHttpRequest 的构造器类
     * @param name       k
     */
    private void removeHeader(Builder reqBuilder, String name) {
        if (StringUtils.isNotEmpty(name)) {
            //  使用 builder 从 ServerHttpRequest 请求头中删除 k-v
            reqBuilder.headers(httpHeaders -> httpHeaders.remove(name));
        }
    }

    /**
     * 授权失败时使用 响应式编程框架回写前端
     *
     * @param serverWebExchange 过滤器拦截
     * @param msg               失败信息
     * @return 用于显示是否成功的 Mono<Void>
     */
    private Mono<Void> unauthorizedResponse(ServerWebExchange serverWebExchange, String msg) {
        LOGGER.error("[鉴权异常处理]请求路径:{}", serverWebExchange.getRequest().getPath());
        // 把 msg 和 code 以 DataStream形式回写前端
        return ServletUtils.webFluxResponseWriter(serverWebExchange.getResponse(), HttpStatus.UNAUTHORIZED, msg, R.FAIL);
    }

    /**
     * 从 ServerHttpRequest中提取简易用户令牌。
     * 该方法和 SecurityUtils中的同名方法基本相同，只是那一个是对 非响应式的ServletHttpRequest 进行处理。
     * <p>
     * 至于为什么能从 serverHttpRequest 的header中提取出名为Authorization的内容，这是由前端决定的。
     * 实际上去看Vue代码能发现，前端在登陆成功后将后端传递的 jwtToken保存下来（怎么保存同样是）
     *
     * @param serverHttpRequest 响应式Request
     * @return Request中携带的简易用户令牌
     */
    private String getJwtToken(ServerHttpRequest serverHttpRequest) {
        // 多了一个getFirst不知道是什么意思，是获取第一个 Authorization 嘛
        String jwtToken = serverHttpRequest.getHeaders().getFirst(TokenConstants.AUTHENTICATION);
        // 因为传到后端的 简易用户令牌信息 到底带不带前缀是由前端代码决定的。
        // 但是这个前缀是啥是还是需要前后端保持相同的，这里我们设置为国际通用的 Bearer+空格+简易令牌信息
        // 检测是否存在令牌前缀，存在则裁剪掉前缀
        if (StringUtils.isNotEmpty(jwtToken) && jwtToken.startsWith(TokenConstants.PREFIX)) {
            jwtToken = jwtToken.replaceFirst(TokenConstants.PREFIX, "");
        }
        return jwtToken;
    }

    /**
     * 刷新缓存中 后端登录令牌 的过期时间。同样和 Security模块中的同名方法完全一样。只是避免依赖才重写的而已
     *
     * @param loginLoginUser 后端登录令牌
     */
    private void refreshToken(LoginUser loginLoginUser) {
        if (StringUtils.isNotNull(loginLoginUser) && StringUtils.isNotEmpty(loginLoginUser.getToken())) {
            // 刷新最新登录时间
            loginLoginUser.setLoginTime(System.currentTimeMillis());
            // 刷新最新过期时间。为最新登录时间+720分钟
            loginLoginUser.setExpireTime(loginLoginUser.getLoginTime() + CacheConstants.EXPIRATION * 1000);
            // 制作用户令牌在缓存时的 key。项目使用 login_tokens: + 用户UserId 作为缓存key
            String tokenCacheKey = getTokenCacheKey(loginLoginUser.getUserid().toString(), loginLoginUser.getToken());
            // 后端缓存用户令牌。过期时间是720分钟
            redissonUtils.setCacheObject(tokenCacheKey, loginLoginUser, CacheConstants.EXPIRATION, TimeUnit.MINUTES);
        }
    }

    /**
     * 记录用户在线。
     *
     * @param loginUserCache 缓存中的 后端登录令牌。
     */
    private void refreshOnlineUser(LoginUser loginUserCache) {
//        // 获取保存记录的 RMapCache
//        RMapCache<String, String> onlineRMapCache = redissonUtils.getRedissonClient().
//                getMapCache(CacheConstants.ONLINE_USER_KEY + "RMapCache");
        SysUserOnline onlineUser = new SysUserOnline();
        onlineUser.setTokenId(loginUserCache.getToken());
        onlineUser.setUserName(loginUserCache.getUsername());
        onlineUser.setUserId(String.valueOf(loginUserCache.getUserid()));
        onlineUser.setIpaddr(loginUserCache.getIpaddr());
        onlineUser.setBrowser("unFinished:响应式");
        onlineUser.setLoginLocation("unFinished:响应式");
        onlineUser.setOs("unFinished:响应式");
        onlineUser.setLoginTime(System.currentTimeMillis());
        redissonUtils.setCacheObject(
                CacheConstants.ONLINE_USER_KEY + loginUserCache.getUserid().toString() + ":" + loginUserCache.getToken(),
                onlineUser,
                10,
                TimeUnit.MINUTES);
//        // 使用put将覆盖插入。新增/更新一起完成
//        onlineRMapCache.put(
//                getOnlineCacheKey(loginUserCache.getUserid().toString(), loginUserCache.getIpaddr(), loginUserCache.getToken()),
//                JSON.toJSONString(onlineUser),
//                10,
//                TimeUnit.MINUTES);
    }

    /**
     * 获取缓存中 后端登录令牌 的 key。
     * 该方法与 meimeistore-manage-backstage-common-security 中 TokenUtils中的同名方法完全相同。只是由于这个模块和meimeistore-manage-backstage-common-security的交集实在太少，因此不依赖。
     *
     * @param userId 用户令牌(不管是简易还是完整)的唯一标识。原版使用uuid，但是这样就完全无法在之后查询令牌。
     *               改为使用userId+userKey作为缓存key
     * @return 加上前缀的key
     */
    private String getTokenCacheKey(String userId, String userKey) {
        return CacheConstants.LOGIN_TOKEN_KEY + userId + ":" + userKey;
    }

    /**
     * 获取缓存中 在线用户哈希表中 在线用户记录的key。
     * 由于可能出现用户在一台电脑上使用不同游览器同时在线，所以需要 ipaddr和token来同时作为缓存key
     */
    private String getOnlineCacheKey(String userId, String ipaddr, String userKey) {
        return CacheConstants.ONLINE_USER_KEY + userId + ":" + ipaddr + ":" + userKey;
    }
}
